# Copyright (C) 2012 by Sean Sherrard.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""gapiclient - Google API Client

A simple python client library for accessing Google API services.
Enables seamless access to both the new REST/JSON based services and
the legacy GData/Atom based services, and provides a streamlined
interface for some of the various Google authentication mechanisms.

This requires the google-api-python-client version 1.0beta7 or more
recent, and also httplib2 0.7.2, both of which should be available
from http://code.google.com/p/google-api-python-client/.
For use with gdata services, it also requires the appropriate 
service description file installled in the same directory.
"""

__author__ = 'Sean Sherrard'
__version__ = '0.2'
__copyright__ = "Copyright 2012, Sean Sherrard"
#__all__ = ['build', 'OAuth2ClientFlow', 'OAuth2DeviceFlow', 'ClientLoginFlow',
#		   'DeveloperKeyFlow', 'showUrlBrowser', 'FileStorage', 'CryptStorage']

import os
import re
import json
import logging
import string
from datetime import datetime, date, time, timedelta, tzinfo
from base64 import b64encode, b64decode
from collections import OrderedDict
from contextlib import contextmanager
from urllib import urlencode
from urlparse import urlsplit
from xml.etree import cElementTree as ElementTree, ElementPath
from httplib2 import Http, Authentication
from oauth2client.client import Storage, OAuth2Credentials, AssertionCredentials
from apiclient.discovery import createResource, STACK_QUERY_PARAMETERS
from apiclient.http import HttpRequest
from apiclient.schema import Schemas
from apiclient.model import BaseModel # , JsonModel
from apiclient.errors import InvalidJsonError, UnknownApiNameOrVersion, HttpError

Crypto = OpenSSL = None
try:
	import Crypto.Hash.SHA256
	import Crypto.PublicKey.RSA
	from binascii import hexlify, unhexlify
except ImportError:
	Crypto = None
	try:
		import OpenSSL.crypto
	except ImportError:
		OpenSSL = None

USER_AGENT = __name__ + '/' + __version__

OAUTH2_AUTH_URI =   'https://accounts.google.com/o/oauth2/auth'
OAUTH2_DEVICE_URI = 'https://accounts.google.com/o/oauth2/device/code'
OAUTH2_TOKEN_URI =  'https://accounts.google.com/o/oauth2/token'
OAUTH2_CERTS_URI =  'https://www.googleapis.com/oauth2/v1/certs'
API_DISCOVERY_URI = 'https://www.googleapis.com/discovery/v1/apis/{api}/{apiVersion}/rest'
CLIENT_LOGIN_URI =  'https://www.google.com/accounts/ClientLogin'
DEVICE_GRANT_URI =  'http://oauth.net/grant_type/device/1.0'
SERVICE_GRANT_URI = 'http://oauth.net/grant_type/jwt/1.0/bearer'
OOB_CALLBACK_URN =  'urn:ietf:wg:oauth:2.0:oob'
OAUTH2_TOKEN_EXPIRE = 3600 # in seconds, = 1 hour

GDATA_SERVICE_NAMES = {'calendar': 'cl',
					   'contacts': 'cp',
					   'docs': 'writely',
					   'spreadsheets': 'wise',
					   'sites': 'jotspot'}

logger = logging.getLogger(__name__)

def build(serviceName, version, http=None, discoveryServiceUrl=API_DISCOVERY_URI,
		  developerKey=None, model=None, requestBuilder=HttpRequest):
	"""Construct a Resource for interacting with a Google Service API.

	Construct a Resource object for interacting with a legacy GData API
	or a new REST/JSON API. The serviceName will first be checked against
	service description files in the installation directory, and then
	with the Google Service Discovery API.

	Args:
		serviceName: string, name of the service
		version: string, the version of the service
		http: httplib2.Http, an http client object
		discoveryServiceUrl: string, a URI Template that points to
		  the location of the discovery service. It should have two
		  parameters {api} and {apiVersion} that when filled in
		  produce an absolute URI to the discovery document for
		  that service.
		developerKey: string, key obtained
		  from https://code.google.com/apis/console
		model: apiclient.Model, converts to and from the wire format
		requestBuilder: apiclient.http.HttpRequest, encapsulator for
		  an HTTP request

	Returns:
		A Resource object with methods for interacting with
		the service.
	"""

	if http is None:
		http = Http()

	fname = os.path.join(os.path.dirname(__file__), '{}_{}.json'.format(serviceName, version))
	with try_open(fname) as file:
		if file:
			content = file.read()
			future = None
		else:
			requestUrl = discoveryServiceUrl.format(api=serviceName, apiVersion=version)
			logger.info('URL being requested: %s', requestUrl)
			resp, content = http.request(requestUrl)
			if resp.status == 404:
				raise UnknownApiNameOrVersion('name: {} version: {}'.format(serviceName, version))
			if resp.status >= 400:
				raise HttpError(resp, content, requestUrl)
			fname = os.path.join(os.path.dirname(createResource.func_code.co_filename),
								 'contrib', serviceName, 'future.json')
			with try_open(fname) as file:
				future = file and file.read()

	try:
		serviceDesc = json.loads(content)
		futureDesc = json.loads(future) if future else {}
	except Exception:
		logger.error('Failed to parse as JSON: "%s..."', content[:100])
		raise InvalidJsonError()

#	schema = Schemas(serviceDesc)
#	baseUrl = serviceDesc['baseUrl']
#	features = serviceDesc.get('features', [])

	if model is None:
		protocol = serviceDesc.get('protocol')
		if protocol == 'gdata':
			model = AtomModel(serviceDesc)
		elif protocol == 'rest':
			model = JsonModel(serviceDesc) # 'dataWrapper' in features)
		else:
			model = RawModel()
#	if protocol == 'gdata' and 'version' in serviceDesc:
#		extraHeaders = {'GData-Version': serviceDesc['version']}
#	else:
#		extraHeaders = None

	# monkeypatch #1: Google apiclient currently ignores global parameters
	#     defined at document level, and instead hard-codes a global
	#     parameter list defined in discovery.py. This copies all of the
	#     global parameters into each method before creating the resource.

	paramDesc = serviceDesc.get('parameters')
	if paramDesc:
		def fixMethods(resourceDesc):
			for methodDesc in resourceDesc.get('methods', {}).itervalues():
				methodDesc.setdefault('parameters', {}).update(paramDesc)
		fixMethods(serviceDesc)
		for resourceDesc in serviceDesc.get('resources', {}).itervalues():
			fixMethods(resourceDesc)

	with clear_sqp():
		resource = createResource(http, serviceDesc['baseUrl'], model,
								  requestBuilder, developerKey, serviceDesc,
								  futureDesc, Schemas(serviceDesc))
#	authDesc = futureDesc.get('auth', {})
	resource.auth_discovery = lambda: futureDesc.get('auth', {})
	resource._serviceDesc = serviceDesc

	# monkeypatch #2: Google apiclient currently ignores parameter default
	#     values, which are assumed to have a null value. We need to have
	#     explicit default values for the 'projection' and 'visibilty'
	#     parameters, so this will set default values for any required
	#     parameters before calling the original method.

	# monkeypatch #3: Support positional arguments to methods by using
	#     the parameterOrder field of the method description.

	# monkeypatch #4: We also need to insert additional headers into the
	#     outgoing request for the GData protocol, and have access to the
	#     request and response schemas in order to convert between Atom and
	#     Python formats. This attaches that information to the model object.

	def replaceMethod(resourceInst, methodName, defaultParams, paramOrder, methodId):
		resourceClass = type(resourceInst)
		oldMethod = getattr(resourceClass, methodName)
		def newMethod(self, *args, **kwargs):
			if len(args) > len(paramOrder):
				raise TypeError('{}() takes at most {} positional arguments'.format(methodName, len(paramOrder)))
			kwargs.update(zip(paramOrder, args))
			for paramName, paramValue in defaultParams.iteritems():
				if paramName not in kwargs:
					kwargs[paramName] = paramValue
			model.methodArgs = kwargs
#			model.headerArgs = {argName: argValue
#								  for argName, argValue in kwargs.iteritems()
#								    if argName in headerParams}
#			model.forceArg = kwargs.get('force')
			model.methodId = methodId
			return oldMethod(self, **kwargs)
#			httpRequest = oldMethod(self, **kwargs)
#			if extraHeaders:
#				httpRequest.headers.update(extraHeaders)
#			if respDesc:
#				httpRequest.postproc = lambda resp, content: model.response(resp, content, respDesc)
#				def postproc(resp, content):
#					return model.response(resp, content, respDesc)
#			oldPostproc = httpRequest.postproc
#			def newPostproc(resp, content):
#				oldPostproc.im_self.respDesc = respDesc
#				return oldPostproc(resp, content)
#			httpRequest.postproc = newPostproc
#			return httpRequest
		newMethod.__name__ = str(methodName)
		newMethod.__doc__ = oldMethod.__doc__
		setattr(resourceClass, methodName, newMethod)

	def fixMethods(resourceInst, resourceDesc):
		for methodName, methodDesc in resourceDesc.get('methods', {}).iteritems():
			defaultParams = {paramName: paramDesc['default'] for paramName, paramDesc
							  in methodDesc.get('parameters', {}).iteritems()
							    if 'default' in paramDesc and paramDesc.get('required')}
#			headerParams = {paramName for paramName, paramDesc
#							  in methodDesc.get('parameters', {}).iteritems()
#							    if paramDesc.get('location') == 'header'}
			paramOrder = methodDesc.get('parameterOrder', ())
#			respDesc = methodDesc.get('response') if isinstance(model, DescModel) else None
			methodId = methodDesc.get('id') if hasattr(model, 'methodId') else None
			if methodId and 'response' not in methodDesc:
				pass # XXX add blank response to methodDesc?
			if defaultParams or headerParams or paramOrder or methodId: # or respDesc or extraHeaders
				replaceMethod(resourceInst, methodName, defaultParams, paramOrder, methodId) # respDesc)

	def replaceResource(resourceInst, resourceName, resourceDesc):
		resourceClass = type(resourceInst)
		oldMethod = getattr(resourceClass, resourceName)
		def newMethod(self):
			with clear_sqp():
				resource = oldMethod(self)
			fixMethods(resource, resourceDesc)
			return resource
		newMethod.__name__ = str(resourceName)
		newMethod.__doc__ = oldMethod.__doc__
		newMethod.__is_resource__ = True
		setattr(resourceClass, resourceName, newMethod)

	fixMethods(resource, serviceDesc)
	for resourceName, resourceDesc in serviceDesc.get('resources', {}).iteritems():
		replaceResource(resource, resourceName, resourceDesc)
	return resource

def OAuth2ClientFlow(storage, showUrlFn, serviceIds, scopes='all', http=None,
					 model=None, requestBuilder=HttpRequest):
	if http is None:
		http = Http()
	resources, scopes = _build_oa2scopes(serviceIds, scopes, http, model, requestBuilder)
	credent = storage.get()
	if credent.invalid:
		params = {'response_type': 'code',
				  'client_id': credent.client_id,
				  'redirect_uri': OOB_CALLBACK_URN,
				  'scope': scopes}
		auth_url = OAUTH2_AUTH_URI + '?' + urlencode(params)
		auth_code = showUrlFn(auth_url)
		params = {'client_id': credent.client_id,
				  'client_secret': credent.client_secret,
				  'code': auth_code,
				  'redirect_uri': OOB_CALLBACK_URN,
				  'grant_type': 'authorization_code'}
		result = _post_oa2params(http, OAUTH2_TOKEN_URI, params)
		credent = _build_oa2credential(result, credent)
		storage.put(credent)
	credent.authorize(http)
	return resources

def OAuth2DeviceFlow(storage, showUrlFn, serviceIds, scopes='all', http=None,
					 model=None, requestBuilder=HttpRequest):
	if http is None:
		http = Http()
	resources, scopes = _build_oa2scopes(serviceIds, scopes, http, model, requestBuilder)
	credent = storage.get()
	if credent.invalid:
		params = {'client_id': credent.client_id,
				  'scope': scopes}
		result = _post_oa2params(http, OAUTH2_DEVICE_URI, params)
		if 'device_code' not in result or 'user_code' not in result or 'verification_url' not in result:
			logger.error('invalid OAuth2 device token: %s', result)
			raise InvalidJsonError
		poll_interval = int(result.get('interval', 5))
		token_expiry = result.get('expires_in')
		token_expiry = token_expiry and (datetime.now() + 
										 timedelta(seconds=int(token_expiry)))
		showUrlFn(result['verification_url'], result['user_code'])
		params = {'client_id': credent.client_id,
				  'client_secret': credent.client_secret,
				  'code': result['device_code'],
				  'grant_type': DEVICE_GRANT_URI}
		while token_expiry is None or datetime.now() < token_expiry:
			result = _post_oa2params(http, OAUTH2_TOKEN_URI, params)
			error = result.get('error')
			if error is None:
				break
			elif error == 'slow_down':
				poll_interval += 1
			elif error != 'authorization_pending':
				break
			sleep(poll_interval)
		else:
			raise RuntimeError('timed out waiting for access token')
		credent = _build_oa2credential(result, credent)
		storage.put(credent)
	credent.authorize(http)
	return resources

def OAuth2ServiceFlow(storage, showUrlFn, serviceIds, scopes='all', http=None,
					  model=None, requestBuilder=HttpRequest):
	class JwtAssertionCredentials(AssertionCredentials):
		def __init__(self, foo):
			AssertionCredentials.__init__(self, SERVICE_GRANT_URI, user_agent, OAUTH2_TOKEN_URI)
		def _generate_assertion(self):
			return _build_oa2jwt(credent, pkey, delegation)

	if http is None:
		http = Http()
	resources, scopes = _build_oa2scopes(serviceIds, scopes, http, model, requestBuilder)
	credent = storage.get()
	if credent.invalid:
		pass # XXX

def _build_oa2jwt(credent, pkey, delegation=None):
	jwtencode = lambda obj: b64encode(json.dumps(obj, separators=(',',':')), '-_').rstrip('=')
	jwtdecode = lambda val: json.loads(b64decode(val + '=' * (-len(val) % 4), '-_'))
	now = int(_time())
	header = jwtencode({'alg': 'RS256', 'typ': 'JWT'})
	claim = {'iss': credent.client_id,
			 'aud': OAUTH2_TOKEN_URI,
			 'iat': now,
			 'exp': now + OAUTH2_TOKEN_EXPIRE,
			 'scope': credent.scopes}
	if delegation:
		claim['prn'] = delegation
	claim = jwtencode(claim)
	input = header + '.' + claim
	sign = _sign_rs256(pkey, input)
	sign = b64encode(sign, '-_').rstrip('=')
	return input + '.' + sign

if Crypto:
	def _sign_rs256(rsakey, input):
		hash = Crypto.Hash.SHA256.new(input).digest()
		sign = rsakey.sign(hash, None)
		return binascii.unhexlify(hex(sign[0])[2:-1])
	def _verify_rs256(rsakey, input, sign):
		hash = Crypto.Hash.SHA256.new(input).digest()
		sign = (int(binascii.hexlify(sign), 16),)
		return rsakey.verify(hash, sign)
	def _get_rsakey(fname, passwd=None):
		with open(fname, 'rb') as file:
			data = file.read()
		return Crypto.PublicKey.RSA.importKey(data, passwd)
	def _encrypt_bytes(data, passwd):
		key = Crypto.Hash.SHA256.new(passwd).digest()
		aes = AES.new(key, AES.MODE_CFB, 'abcdefghijklmnop')
		return aes.encrypt(data)
	def _decrypt_bytes(data, passwd):
		key = Crypto.Hash.SHA256.new(passwd).digest()
		aes = AES.new(key, AES.MODE_CFB, 'abcdefghijklmnop')
		return aes.decrypt(data)
	def _parse_pem(fname):
		with open(fname, 'r') as file:
			data = file.read()
		items = []
		kind = content = attr = inattr = None
		for line in data.splitlines():
			if inattr and line and line[0].isspace():
				key, sep, val = line.strip().partition(': ')
				if sep:
					attr[key] = val
			elif line and line[0] == line[-1] == '-':
				inattr = False
				verb, _kind = line.strip('-').split(None, 1)
				if verb == 'BEGIN':
					kind = _kind
					content = []
				elif verb == 'END' and _kind == kind:
					content = b64decode(''.join(content))
					items.append((kind, content, attr))
					kind = content = attr = None
				else:
					kind = content = attr = None
			elif content is not None:
				content.append(line)
			elif line == 'Bag Attributes':
				inattr = True
				attr = {}
			else:
				inattr = False
		return items





elif OpenSSL:
	def _sign_rs256(pkey, input):
		return OpenSSL.crypto.sign(pkey, input, 'sha256')
	def _verify_rs256(pkey, input, sign):
		try:
			OpenSSL.crypto.verify(pkey, sign, input, 'sha256')
		except:
			return False
		else:
			return True
	def _get_rsakey(fname, passwd=None):
		with open(fname, 'rb') as file:
			data = file.read()
		return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, data, passwd)
else:
	pass # XXX

def ClientLoginFlow(email, passwd, source, serviceId, accountType='HOSTED_OR_GOOGLE',
					http=None, model=None, requestBuilder=HttpRequest):

	class ClientLoginAuthentication(Authentication):
		def __init__(self, auth, host, path):
			Authentication.__init__(self, auth, host, path, None, None, None, None)
		def request(self, method, request_uri, headers, content):
			headers['authorization'] = 'GoogleLogin Auth=' + self.credentials

	if http is None:
		http = Http()
	name, _, version = serviceId.partition(':')
	resource = build(name, version, http, model=model, requestBuilder=requestBuilder)
	service = resource._serviceDesc.get('auth',{}).get('clientlogin',{}).get('service') \
				or GDATA_SERVICE_NAMES.get(name, name)
	headers = {'content-type': 'application/x-www-form-urlencoded',
			   'accept': 'text/plain'}
	params = {'accountType': accountType,
			  'Email': email,
			  'Passwd': passwd,
			  'service': service,
			  'source': source}
	resp, cont = http.request(CLIENT_LOGIN_URI, 'POST', urlencode(params), headers)
	auth = dict([line.split('=', 1) for line in cont.splitlines() if '=' in line]).get('Auth')
	_, host, path, _, _ = urlsplit(resource._serviceDesc.get('baseUrl',''))
	if resp.status != 200 or not auth:
		logger.error('invalid ClientLogin response')
		raise HttpError(resp, cont, CLIENT_LOGIN_URI)
	http.authorizations.append(ClientLoginAuthentication(auth, host, path))
	return resource

def DeveloperKeyFlow(key, serviceId, http=None, model=None, requestBuilder=HttpRequest):
	if http is None:
		http = Http()
	name, _, version = serviceId.partition(':')
	resource = build(name, version, http, developerKey=key, model=model, requestBuilder=requestBuilder)
	return resource

def showUrlBrowser(url, code=None):
	import webbrowser
	print 'Opening authentication URL in your system web browser.'
	webbrowser.open_new(url)
	if code is None:
		return raw_input('Please authenticate and then enter code here:')
	else:
		print 'Please enter this device code and then authenticate:', code

def _build_oa2scopes(services, scopes, http, model, requestBuilder):
	if isinstance(services, basestring):
		services = [services]
		multi = False
	else:
		multi = True
	resources = [build(name, versn, http, model=model, requestBuilder=requestBuilder)
				 for svcid in services for name, _, versn in [svcid.partition(':')]]
	def getscopes(resource):
		return resource._serviceDesc.get('auth',{}).get('oauth2',{}).get('scopes')
	if scopes == 'all':
		scopes = []
		for resource in resources:
			scopes.extend(getscopes(resource))
	elif scopes == 'readonly':
		scopes = []
		for resource in resources:
			scopes.extend([scope for scope in getscopes(resource)
						   if 'readonly' in scope])
	if not isinstance(scopes, basestring):
		scopes = ' '.join(scopes)
	return (resources if multi else resources[0]), scopes

def _build_oa2credential(result, credent):
	if 'error' in result or 'access_token' not in result:
		logger.error('invalid OAuth2 access token: %s', result)
		raise InvalidJsonError
	refresh_token = result.get('refresh_token')
	token_expiry = result.get('expires_in')
	token_expiry = token_expiry and (datetime.utcnow() + 
									 timedelta(seconds=int(token_expiry)))
	return OAuth2Credentials(result['access_token'], credent.client_id, 
							 credent.client_secret, refresh_token,
							 token_expiry, token_uri, credent.user_agent)

def _post_oa2params(http, url, params):
	body = urlencode(params)
	headers = {'content-type': 'application/x-www-form-urlencoded',
			   'accept': 'application/json'}
	resp, cont = http.request(url, 'POST', body, headers)
	type = resp.get('content-type', '')
	if type.startswith('application/json'):
		try:
			cont = json.loads(cont)
		except Exception:
			raise InvalidJsonError
	if resp.status < 300:
		return cont
	else:
		logger.error('failed to retrieve resource at %s', url)
		if isinstance(cont, dict) and 'error' in cont:
			cont = str(cont['error'])
		raise HttpError(resp, cont, url)

class FileStorage(Storage):
	def __init__(self, filename):
		self._filename = filename
	def locked_get(self):
		credent = OAuth2Credentials.new_from_json(self.read())
		credent.store = self
		return credent
	def locked_put(self, credent):
		self.write(credent.to_json())
		credent.store = self
	def read(self):
		with open(self._filename, 'r') as file:
			return file.read()
	def write(self, data):
		with open(self._filename, 'w') as file:
			file.write(data)

class CryptStorage(FileStorage):
	_b64alpha = string.uppercase + string.lowercase + string.digits + '+/'
	def __init__(self, filename, key):
		FileStorage.__init__(self, filename)
		b64alpha = CryptStorage._b64alpha
		key = b64encode(key).rstrip('=')
		self._encmap = tuple({'=':'='} for chr in key)
		self._decmap = tuple({'=':'='} for chr in key)
		for keyidx, keychr in enumerate(key):
			offset = b64alpha.index(keychr)
			for b64idx, b64chr in enumerate(b64alpha):
				offchr = b64alpha[(b64idx + offset) % 64]
				self._encmap[keyidx][b64chr] = offchr
				self._decmap[keyidx][offchr] = b64chr
	def read(self):
		decmap = self._decmap
		keylen = len(decmap)
		with open(self._filename, 'r') as file:
			data = file.read()
		return b64decode(''.join([decmap[idx%keylen][chr] for idx, chr in enumerate(data)]))
	def write(self, data):
		encmap = self._encmap
		keylen = len(encmap)
		data = ''.join([encmap[idx%keylen][chr] for idx, chr in enumerate(b64encode(data))])
		with open(self._filename, 'w') as file:
			file.write(data)

def create_oauth2credential(storage, client_id, client_secret, source):
	user_agent = '/'.join(source.rsplit('-', 1))
	credent = OAuth2Credentials(None, client_id, client_secret, None, None, None, user_agent)
	credent.invalid = True
	storage.put(credent)

def cached(maxsize=None, wrapper=None):
	"""Decorator that caches a function's return value each time it is called.
	If called later with the same arguments, the cached value is returned, and
	not re-evaluated. The cache can be limited to a maximum size and uses a
	pseudo-random replacement strategy.
	"""
	def makecache(func):
		cache = {}
		def cachefn(*args, **kwargs):
			key = (args, tuple(kwargs.items()) if kwargs else None)
			try:
				value = cache[key]
			except KeyError:
				value = func(*args, **kwargs)
				if maxsize and len(cache) >= maxsize:
					cache.popitem()
				cache[key] = value
			except TypeError:
				value = func(*args, **kwargs)
			return value
		cachefn.__name__ = func.__name__
		cachefn.__module__ = func.__module__
		cachefn.__defaults__ = func.__defaults__
		cachefn.__doc__ = func.__doc__
		cachefn._cache = cache
		return wrapper(cachefn) if wrapper else cachefn
	return makecache

@contextmanager
def try_open(filename, mode='r'):
	try:
		f = open(filename, mode)
	except IOError:
		yield None
	else:
		try:
			yield f
		finally:
			f.close()

@contextmanager
def clear_sqp():
	saved_sqp = STACK_QUERY_PARAMETERS[:]
	try:
		del STACK_QUERY_PARAMETERS[:]
		yield
	finally:
		STACK_QUERY_PARAMETERS[:] = saved_sqp

#class frozendict(dict):
#	__slots__ = ()
#	def __hash__(self):
#		return hash(tuple(self.items()))
#	def __repr__(self):
#		return 'frozendict({})'.format(dict.__repr__(self))
#	def __error(*args, **kwargs):
#		raise TypeError('frozendict instances are immutable')
#	__setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = __error


class AtomModel(BaseModel):
	"""Model class for Atom-based Google Data Protocol.

	Serializes and de-serializes between Atom format and the Python
	object representation of HTTP request and response bodies.
	"""

	accept = 'application/atom+xml'
	content_type = 'application/atom+xml'
	alt_param = 'atom'
	no_content_response = None
	default_ns = 'atom'

	nsmap = {'atom':	   'http://www.w3.org/2005/Atom',
			 'gd':		   'http://schemas.google.com/g/2005',
			 'gs':		   'http://schemas.google.com/spreadsheets/2006',
			 'gsx':		   'http://schemas.google.com/spreadsheets/2006/extended',
			 'batch':	   'http://schemas.google.com/gdata/batch',
			 'openSearch': 'http://a9.com/-/spec/opensearch/1.1/'}

	typemap = {'string':  lambda val: str(val),
			   'number':  lambda val: float(val),
			   'integer': lambda val: int(val),
			   'boolean': lambda val: val.strip() == 'true',
			   'null':    lambda val: None}

	for pref, uri in nsmap.iteritems():
		ElementTree.register_namespace(pref, uri) 

	def __init__(self, serviceDesc):
		self.methodId = None
		self.methodArgs = None
		self.serviceDesc = serviceDesc
#		self.version = serviceDesc.get('version')
		self._schemas = {schDesc['id']: schDesc 
						  for schDesc in serviceDesc.get('schemas', {}).itervalues()}
		self._methods = {methDesc['id']: methDesc 
						  for methDesc in serviceDesc.get('methods', {}).itervalues()}
		self._methods.update({methDesc['id']: methDesc 
								for resDesc in serviceDesc.get('resources', {}).itervalues()
								  for methDesc in resDesc.get('methods', {}).itervalues()})

	@cached(maxsize=200, wrapper=classmethod)
	def getXPath(cls, path):
		return XPath(path, cls.nsmap, cls.default_ns)

	def getSchema(self, desc):
		seen = set()
		while '$ref' in desc:
			ref = desc['$ref']
			if ref in seen:
				raise ValueError("loop in schema reference '{}'".format(ref))
			seen.add(ref)
			try:
				desc = self._schemas[ref]
			except KeyError:
				raise ValueError("unknown schema reference '{}'".format(ref))
		return desc

	def getMethodItem(self, item, default=None):
		try:
			return self._methods[self.methodId].get(item, default)
		except KeyError:
			raise ValueError("unknown method id '{}'".format(self.methodId))

	def request(self, headers, path_params, query_params, body_value):
		if 'version' in self.serviceDesc:
			headers['GData-Version'] = self.serviceDesc['version']
		if 'force' in self.getMethodItem('parameters', ()):
			if self.methodArgs.get('force'):
				headers['If-Match'] = '*'
			elif isinstance(body_value, dict) and 'etag' in body_value:
				headers['If-Match'] = body_value['etag']
		return BaseModel.request(self, headers, path_params, query_params, body_value)

#		query = self._build_query(query_params)
#		headers['accept'] = self.accept
#		headers['accept-encoding'] = 'gzip, deflate'
#		if 'user-agent' in headers:
#			headers['user-agent'] += ' ' + USER_AGENT
#		else:
#			headers['user-agent'] = USER_AGENT
#		if body_value is not None:
#			headers['content-type'] = self.content_type
#			body_value = self.serialize(body_value, desc)
#		self._log_request(headers, path_params, query, body_value)
#		return (headers, path_params, query, body_value)

#	def response(self, resp, content):
#		self._log_response(resp, content)
#		if resp.status == 204:
#			return self.no_content_response
#		elif resp.status < 300:
#			return self.deserialize(content, desc)
#		else:
#			logger.debug('Content from bad request was: "%s..."', content[:100])
#			raise HttpError(resp, content)

	def serialize(self, body_value):
		logger.debug('AtomModel serializing %s: "%s..."', self.methodId, str(body_value)[:200])
		pass

	def deserialize(self, content):
		logger.debug('AtomModel deserializing %s: "%s..."', self.methodId, content[:200])
		desc = self.getMethodItem('response')
		root = ElementTree.fromstring(content)
		return self._getobj(root, desc)

	def _getobj(self, elem, desc, name=None):
		desc = self.getSchema(desc)
#		attr = func = None
#		print 'xpath:', desc.get('xpath')
#		path, sep, func = desc.get('xpath', '').partition('=>')
#		xpath = XPath(path or name, nsmap, defaultns)
		type = desc.get('type')
		scalar = type in self.typemap # ('string', 'integer', 'number', 'boolean', 'null')
		xpath = desc.get('xpath')
		format = desc.get('format')
		pattern = desc.get('pattern')
		if not name or not xpath:
			xpath = name or '.'
			if scalar:
			   xpath += '/text()'
#		if name is None:
#			xpath = 'text()' if scalar else '.'
#		elif not xpath:
#			xpath = name + ('/text()' if scalar else '')
#		logger.debug('path: %s', xpath)
		xpath = self.getXPath(xpath)
#		xpath = XPath(xpath, self.nsmap, self.default_ns)
#		logger.debug('xpath: %s %s', xpath.path, xpath.last)
#		path = desc.get('xpath', name + '/text()' if scalar else name)

		if scalar:
##			xpath.func = self.scalar_typemap[type].format(xpath.func or '$')
			val = xpath.findfirst(elem)
			logger.debug('val: %s', val)
			if val is not None and pattern:
				params = self.methodArgs
				params['$'] = self.serviceDesc
				params['_'] = '(.+)'
				try:
					val = re.match(pattern.format(**params) + '$', val).group(1)
				except Exception:
					logger.warning('unable to match value "%s" to pattern "%s"', val, pattern)
					val = None
			if val is not None:
				try:
					val = self.typemap[type](val)
				except Exception:
					logger.warning('unable to convert value "%s" to schema type %s', val, type)
					val = None
#			if val is not None and format:
#				if format == 'date-time':
#					val = googdate(val)

		elif type == 'object':
			val = None
			elem = xpath.findfirst(elem)
			if elem is not None:
				if 'properties' in desc:
					val = {name: val
							for name, desc in desc['properties'].iteritems()
							 for val in [self._getobj(elem, desc, name)]
							  if val is not None}
				if 'additionalProperties' in desc: # and 'xpath' in desc['additionalProperties']:
					adesc = desc['additionalProperties']
					if isinstance(adesc, dict) and 'xpath' in adesc:
						xpath = self.getXPath(adesc['xpath'])
						val = OrderedDict(val or {})
						val.update((child.tag[child.tag.find('}') + 1:], val)
									 for child in xpath.iterfind(elem)
									  for val in [self._getobj(child, adesc)]
									   if val is not None)

		elif type == 'array':
			val = None
			if 'items' in desc:
				idesc = desc['items']
				if isinstance(idesc, dict):
					val = [val for child in xpath.iterfind(elem)
							for val in [self._getobj(child, idesc)]
							 if val is not None]
				elif isinstance(idesc, list):
					val = [val for idesc, child in zip(idesc, xpath.iterfind(elem))
							for val in [self._getobj(child, idesc)]
							 if val is not None]
			if 'additionalItems' in desc and val is not None:
				adesc = desc['additionalItems']
				if isinstance(adesc, dict) and 'xpath' in adesc:
					xpath = self.getXPath(adesc['xpath'])
					val.extend(val for child in xpath.iterfind(elem)
								for val in [self._getobj(child, adesc)]
								 if val is not None)

		else:
			val = None
			logger.warning('unknown schema type "%s"', type)

		if val is None and desc.get('required'):
			logger.warning('required schema item "%s" not found', name)
		return val

_parts = {'d4': r'(\d\d\d\d)',
		  'd2': r'(\d\d)',
		  'ms': r'(\.\d+)',
		  'tz': r'(Z|[+-](\d\d):(\d\d))'}
date_re = re.compile(r'{d4}-{d2}-{d2}'.format(**_parts))
dtime_re = re.compile(r'{d4}-{d2}-{d2}T{d2}:{d2}:{d2}{ms}?{tz}?'.format(**_parts))

class FixedTimeZone(tzinfo):
	def __init__(self):
		self._offset = timedelta(minutes)
	def dst(self, dt):
		return timedelta(0)
	def tzname(self, dt):
		return self._tzname
	def utcoffset(self, dt):
		return self._offset
	@cached(wrapper=staticmethod)
	def from_offset(tz, oh, om):
		if tz == 'Z':
			return FixedTimeZone(0)
		elif tz:
			return FixedTimeZone(int(tz[0] + '1') * (int(oh)*60 + int(om)))
		else:
			return None

def parse_format(value, format):
	if format == 'date-time':
		yr,mo,dy,hr,mi,sc,ms,tz,oh,om = dtime_re.match(value).groups()
		tzinfo = FixedTimeZone.from_offset(tz, oh, om)
		value = datetime(int(yr), int(mo), int(dy), int(hr), int(mi), int(sc),
						 int(float(ms)*1000000) if ms else 0, tzinfo)
	elif format == 'date':
		yr, mo, dy = date_re.match(value).groups()
		value = date(int(yr), int(mo), int(dy))
	elif format == 'time':
		pass
#	elif format


class XPath(object):
	"""An XPath implementation for ElementTree.

	This adds functionality that is missing from the ElementPath module in
	the standard library, including:
	- selecting text() nodes
	- selecting @attribute values
	- selecting nodes that match "prefix:*"
	- applying a default namespace to location paths
	- recursively creating nodes corresponding to an xpath
	"""
	__slots__ = ['path', 'last', 'findfirst', 'iterfind', 'create']

#		self.regex = re.compile(r'(^[^@].*?)?(?:(?:/@|^@)((?:\w+:)?\w+))?(?:=>(.*))?$')
#		self.regex = re.compile(r'(?:^|/)(@?(?:([\w.-]+):)?[\w.-]+(?:\[.*?\])*)|=>(.*$)')
#		self.regex = re.compile(r'(?:^|/)([\w.-]+:)?([\w.-]+(?:\[.*?\])*)|(?:^|/)@([\w.:-]+)|=>(.*)$')
#		self.regex = re.compile(r'(?:^|/)([a-z-]::|@?)(?:([\w.-]+):)?([\w.-]+(?:\[.*?\])*)')
	_axis_re = r'([a-z-]+::|@)'
	_node_re = r'([\w.-]+:)?(\*|[\w.-]+(?:\(\))?)'
	_pred_re = r'((?:\[.*?\])+)'
#	_step_re = r'{}?{}{}?'.format(_axis_re, _node_re, _pred_re)
	_steps_re = re.compile(r'(?:/|^)(?:{}?{}{}?)?([^/]*)'.format(_axis_re, _node_re, _pred_re))
	_preds_re = re.compile(r"\[@{}='(.+?)'\]".format(_node_re))
#	_regex = re.compile(r'(?:/|^)(?:([a-z-]+::|@)?([\w.-]+:)?(\*|[\w.-]+(?:\(\))?)((?:\[.*?\])+)?)?([^/]*)')

	def __new__(cls, path, nsmap=None, defns=None):
		self = object.__new__(cls)
		try:
			self._parse_path(path, nsmap, defns)
#			self.path, self.last, self.func, self._findfirst, self._iterfind = \
#				cls._parse_path(path, frozendict(nsmap), defns)
		except Exception:
			logger.error('Failed to parse xpath: %s', path)
			raise
		else:
			return self

	def _parse_path(self, path, nsmap, defns):
		if path.startswith('/'):
			raise ValueError("invalid absolute xpath '{}'".format(path))
		steps = []

		for match in self._steps_re.finditer(path):
#			print match.group()
#			print match.groups()
			axis, pref, local, pred, rest = match.groups('')
			step = axis + pref + local + pred
#			if rest.startswith('=>'):
#				func = path[match.start(5)+2:]
			if rest:
				raise ValueError("invalid xpath component '{}'".format(rest))
			if axis.endswith('::'):
				if axis == 'attribute::':
					axis = '@'
				elif axis == 'child::':
					axis = ''
				elif axis == 'descendant-or-self::' and local in ('*', 'node()'):
					axis = local = ''
				elif axis == 'self::' and local in ('*', 'node()'):
					axis, local = '', '.'
				elif axis == 'parent::' and local in ('*', 'node()'):
					axis, local = '', '..'
			if axis not in ('', '@'):
				raise ValueError("invalid xpath axis '{}'".format(step))
			if local in ('', '.', '..'):
				if axis or pref or pred:
					raise ValueError("invalid xpath abbreviated component '{}'".format(step))
			elif local[0] in '.-0123456789':
				raise ValueError("invalid xpath node name '{}'".format(step))
			elif local.endswith('()'):
				if pref or local not in ('text()', 'node()'):
					raise ValueError("invalid xpath node test '{}'".format(step))
				if local == 'node()':
					local = '*'
#			if ((axis or pref or pred) if (local in ('', '.', '..') or local.endswith('()'))
#					else local[0] in '.-0123456789'):
#				raise ValueError("invalid xpath component '{}'".format(match.group()))
			if (defns and not pref and not axis and 
					local not in ('', '.', '..', '*') and not local.endswith('()')):
				pref = defns + ':'
			steps.append((axis, pref, local, pred))
#			if pref:
#				pref = pref[:-1]
#				if nsmap is None or pref[:-1] not in nsmap:
#					raise ValueError("unknown xpath prefix '{}'".format(pref[:-1]))
#			if rest:
#				break

#		epiterfind = ElementPath.iterfind
#		_iterfind = lambda elem: etiterfind(elem, path, nsmap)
#		_findfirst = lambda elem: next(etiterfind(elem, path, nsmap), None)
		def maketag(pref, local):
			try:
				return '{{{}}}{}'.format(nsmap[pref[:-1]], local) if pref else local
			except (KeyError, TypeError):
				raise ValueError("unknown xpath prefix '{}'".format(pref[:-1]))
		def makepath(steps):
			return '/'.join([''.join(step) for step in steps]) or '.'
		def makecomps(steps):
			return [(maketag(pref, local),
					 {maketag(_pref, _local): _val
							for _pref, _local, _val in self._preds_re.findall(pred)})
					for axis, pref, local, pred in steps]

		if axis == '@':
			steps = steps[:-1]
			path = makepath(steps)
			comps = makecomps(steps)
			last = maketag(pref, local)
			getattr = lambda elem: next(getattrs(elem), None)
			if local == '*' and pref:
				getattrs = lambda elem: (val for key, val in elem.attrib.iteritems()
											 if key.startswith(last[:-1]))
			elif local == '*':
				getattrs = lambda elem: elem.attrib.itervalues()
			else:
				getattr = lambda elem: elem.get(last)
				getattrs = lambda elem: iter([elem.get(last)])
			if path == '.':
				findfirst = getattr
				iterfind = getattrs
				def create(elem, value):
					elem.set(last, value)
			else:
				def findfirst(elem):
					elem = elem.find(path, nsmap)
					if elem is not None:
						return getattr(elem)
				def iterfind(elem):
					for elem in elem.iterfind(path, nsmap):
						for val in getattrs(elem):
							yield val
				def create(elem, value):
					for tag, attrib in comps:
						elem = ElementTree.SubElement(elem, tag, attrib)
					elem.set(last, value)

		elif local == 'text()':
			steps = steps[:-1]
			path = makepath(steps)
			comps = makecomps(steps)
			last = local
			alltext = lambda elem: (val for val in [elem.text] + 
										[child.tail for child in elem] if val)
			if path == '.':
				findfirst = lambda elem: elem.text
				iterfind = alltext
				def create(elem, value):
					elem.text = value
			else:
				def findfirst(elem):
					elem = elem.find(path, nsmap)
					if elem is not None:
						return elem.text
				def iterfind(elem):
					for elem in elem.iterfind(path, nsmap):
						for val in alltext(elem):
							yield val
				def create(elem, value):
					for tag, attrib in comps:
						elem = ElementTree.SubElement(elem, tag, attrib)
					elem.text = value

		elif pref and local == '*':
			steps = steps[:-1] + [('', '', '*', pred)]
			path = makepath(steps)
			comps = makecomps(steps)
			last = maketag(pref, local)
			def findfirst(elem):
				for elem in elem.iterfind(path, nsmap):
					if elem.tag.startswith(last[:-1]):
						return elem
			def iterfind(elem):
				for elem in elem.iterfind(path, nsmap):
					if elem.tag.startswith(last[:-1]):
						yield elem
			def create(elem, value):
				raise NotImplementedError('I have no idea')

		else:
			path = makepath(steps)
			comps = makecomps(steps)
			last = None
			if path == '.':
				findfirst = lambda elem: elem
				iterfind = lambda elem: iter([elem])
				def create(elem, value):
					raise RuntimeError('this should never happen')
			else:
				findfirst = lambda elem: elem.find(path, nsmap)
				iterfind = lambda elem: elem.iterfind(path, nsmap)
				def create(elem, value):
					for tag, attrib in comps:
						elem = ElementTree.SubElement(elem, tag, attrib)

		self.path = path
		self.last = last
		self.findfirst = findfirst
		self.iterfind = iterfind
		self.create = create
#		return path, last, func, _findfirst, _iterfind

#	def _evalfunc(self, val):
#		if self.func and val is not None:
#			try:
#				val = eval(self.func.replace('$', 'val'))
#			except Exception:
#				logger.exception('error evaluting xpath expression "%s" on "%s"', self.func, val)
#				val = None
#		return val

#	def findfirst(self, elem):
#		return self._evalfunc(self._findfirst(elem))

#	def iterfind(self, elem):
#		return self._evalfunc(self._iterfind(elem))

